Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

15장. Scope Function

코틀린 코드를 읽다 보면
낯선 모양의 함수들을 자주 만나게 됩니다.

user?.let { println(it.name) }

val car = Car().apply {
    color = "red"
    price = 3000
}

let, run, with, apply, also.
바로 이 다섯 함수를 스코프 함수(scope function)라고 부릅니다.

스코프(scope)란 “범위“라는 뜻입니다.
이 함수들은 어떤 객체를 하나 받아서,
그 객체를 다루는 “잠깐의 작업 범위“를 만들어 줍니다.

말이 어렵지만, 하는 일은 단순합니다.

어떤 객체를 잠깐 데려와서,
그 안에서 편하게 일 처리를 하고,
결과를 돌려주는 것.

문제는 다섯 함수가 서로 너무 비슷하다는 점입니다.
생김새가 닮아서 초보자에게는 다 똑같아 보입니다.

실제로 코틀린을 배우는 사람들이
“가장 헷갈린다“고 꼽는 주제가 바로 이것입니다.

이 장에서는 다섯 함수를 하나씩 뜯어보고,
차이를 표로 정리한 다음,
“언제 무엇을 쓸지“까지 정해 보겠습니다.

이 함수들은 반드시 외워야 하는 문법이 아닙니다.
“코드를 더 깔끔하게 만들어 주는 도구“입니다.

그러니 부담 없이 읽어 나가 봅시다.


15.1 let

객체를 받아 결과를 돌려주는 함수

let은 가장 먼저 익혀 두면 좋은 스코프 함수입니다.

let은 어떤 객체를 하나 받아서,
그 객체를 가지고 무언가를 한 다음,
마지막 줄의 결과를 돌려줍니다.

먼저 코드로 보겠습니다.

val name = "kotlin"

val length = name.let {
    println("문자열: $it")
    it.length
}

println(length)   // 5

여기서 itlet이 받은 객체,
name을 가리킵니다.

it은 “그것“이라는 뜻입니다.
“방금 넘긴 그 객체“라고 생각하면 됩니다.

let 블록 안의 마지막 줄이 it.length이므로,
let은 그 값(5)을 돌려줍니다.

let은 블록의 “마지막 줄“을 결과로 돌려준다.

이 규칙만 기억해도 절반은 이해한 것입니다.

null 안전 처리에서의 활용

let이 진짜 빛나는 순간은 따로 있습니다.
바로 null일 수 있는 값을 다룰 때입니다.

앞에서 타입 뒤의 ?
“null이 들어올 수 있음“을 뜻한다고 배웠습니다. (2.4)

null일 수 있는 값은
?.let { } 형태로 자주 처리합니다.

val name: String? = "홍길동"

name?.let {
    println("이름 길이: ${it.length}")
}

여기서 ?.
“값이 null이 아닐 때만 다음을 실행하라“는 뜻입니다.

즉 위 코드는 이렇게 동작합니다.

  • name이 null이면 → let 블록을 아예 실행하지 않음
  • name이 null이 아니면 → let 블록을 실행

덕분에 블록 안에서는
it이 null이 아님이 보장됩니다.

자바에서는 같은 일을
if (name != null)로 감싸야 했습니다.

// 자바
if (name != null) {
    System.out.println("이름 길이: " + name.length());
}

코틀린은 ?.let { } 한 줄로 대신합니다.

“값이 있을 때만 이 일을 하라“를
짧게 표현하는 것이 ?.let이다.

null 안전성은 3부에서 더 깊이 다룹니다.
지금은 “let이 null 처리에 잘 어울린다” 정도로 충분합니다.


15.2 run

계산 후 결과를 반환할 때

runlet과 아주 비슷합니다.
객체를 받아서, 마지막 줄을 결과로 돌려줍니다.

딱 하나 다른 점이 있습니다.
객체를 부르는 방식입니다.

let은 객체를 it으로 부르지만,
run은 객체를 this로 부릅니다.

this는 “이 객체 자신“을 뜻합니다.
그리고 this는 대개 생략할 수 있습니다.

val name = "kotlin"

val result = name.run {
    println("문자열: $this")
    length            // this.length 인데 this 생략
}

println(result)   // 5

it.length 대신 그냥 length라고 쓴 점에 주목합시다.
run 블록 안에서는 객체가 곧 this이므로,
그 객체의 기능을 바로 부를 수 있습니다.

run은 객체의 기능을 자기 것처럼 쓰면서,
마지막 줄을 결과로 돌려준다.

thisit의 차이는 15.6에서 따로 정리합니다.
지금은 “runthis, letit” 정도만 기억해 둡시다.

객체 없이 블록을 실행하는 run

run에는 조금 다른 사용법도 있습니다.
객체에 붙이지 않고, 그냥 단독으로 쓰는 방식입니다.

val value = run {
    val a = 3
    val b = 4
    a + b
}

println(value)   // 7

이 경우 run
“여러 줄을 묶어서 하나의 결과를 만드는” 용도입니다.

임시로 쓸 변수(a, b)를
run 블록 안에 가둬 둘 수 있다는 장점이 있습니다.

블록이 끝나면 a, b는 사라지므로,
바깥 코드가 깔끔하게 유지됩니다.


15.3 with

하나의 객체에 여러 작업을 수행할 때

with는 하나의 객체를 놓고
그 객체의 기능을 여러 번 부를 때 편합니다.

with는 조금 다르게 생겼습니다.
객체를 뒤에 붙이지 않고, 괄호 안에 넣습니다.

val builder = StringBuilder()

with(builder) {
    append("Hello")
    append(", ")
    append("World")
}

println(builder)   // Hello, World

StringBuilder는 문자열을 조금씩 이어 붙일 때 쓰는 도구입니다.
append는 뒤에 글자를 덧붙이는 기능입니다.

with 블록 안에서는 객체가 this이므로,
builder.append(...)를 그냥 append(...)로 쓸 수 있습니다.

같은 객체 이름을 반복하지 않아도 되니
코드가 훨씬 짧아집니다.

with를 안 쓰면 이렇게 됩니다.

builder.append("Hello")
builder.append(", ")
builder.append("World")

builder가 세 번이나 반복됩니다.
with는 이 반복을 없애 줍니다.

with는 “이 객체를 가지고 이런 일들을 하라“를
한 묶음으로 표현한다.

run과 비슷하지만 다른 점

여기까지 보면 withrun과 거의 같아 보입니다.
실제로 둘 다 객체를 this로 다룹니다.

차이는 “부르는 형태“에 있습니다.

함수부르는 방법
run객체.run { ... }
withwith(객체) { ... }

이 작은 차이가 실제로는 꽤 중요합니다.
바로 null 처리에서 갈립니다.

run은 객체 뒤에 붙기 때문에
객체?.run { }처럼 null 안전 호출과 잘 어울립니다.

하지만 with는 그렇게 쓰기 어색합니다.
그래서 다음과 같이 정리해 두면 편합니다.

  • null일 수 있는 객체 → run
  • null이 아닌 게 확실한 객체 → with

15.4 apply

객체 설정 후 자기 자신을 반환

apply는 이름 그대로
“객체에 설정을 적용(apply)“할 때 씁니다.

지금까지 본 함수들은
블록의 마지막 줄을 결과로 돌려줬습니다.

apply는 다릅니다.
블록이 끝나면 “객체 자기 자신“을 돌려줍니다.

간단한 클래스를 하나 두고 보겠습니다.
(클래스는 4부에서 배우니 지금은 가볍게 봅시다.)

class Car {
    var color: String = ""
    var price: Int = 0
}

val car = Car().apply {
    color = "red"
    price = 3000
}

println("${car.color}, ${car.price}")   // red, 3000

Car()로 자동차를 하나 만들고,
apply 블록 안에서 색과 가격을 정합니다.

블록 안에서는 객체가 this이므로,
car.color 대신 그냥 color라고 씁니다.

그리고 apply는 설정을 끝낸 그 객체를 돌려주므로,
바로 car에 담을 수 있습니다.

apply는 “객체를 만들면서 값도 채워 넣는”
상황에 딱 맞는다.

초기화에 자주 쓰이는 패턴

apply는 객체를 만들자마자
초기 설정을 몰아서 할 때 특히 자주 쓰입니다.

apply를 안 쓰면 이렇게 됩니다.

val car = Car()
car.color = "red"
car.price = 3000

car라는 이름이 계속 반복됩니다.
apply는 이 반복을 없애 줍니다.

val car = Car().apply {
    color = "red"
    price = 3000
}

한눈에 “이 자동차를 이렇게 설정한다“가 읽힙니다.
객체를 만들고 값을 채우는 코드는
백엔드에서 정말 자주 나옵니다.

그래서 apply
실무에서 가장 많이 쓰는 스코프 함수 중 하나입니다.


15.5 also

부수적인 작업을 수행할 때

also는 “게다가, 또한“이라는 뜻입니다.
이름처럼 “본래 하던 일에 무언가를 덧붙일 때” 씁니다.

also도 객체 자기 자신을 돌려줍니다.
apply와 반환 규칙이 같습니다.

대신 객체를 it으로 부릅니다.
applythis인 것과 반대입니다.

val numbers = mutableListOf(1, 2, 3)

numbers
    .also { println("추가 전: $it") }
    .add(4)

println(numbers)   // [1, 2, 3, 4]

also 블록은 객체를 그대로 돌려주므로,
그 뒤에 .add(4)를 이어서 부를 수 있습니다.

also는 흐름을 끊지 않고
“잠깐 곁다리 작업“을 끼워 넣습니다.

대표적인 곁다리 작업이 로그 출력입니다.

val user = createUser()
    .also { println("사용자 생성됨: ${it.name}") }

객체를 만드는 흐름은 그대로 두고,
“만들어졌다“는 기록만 살짝 추가한 것입니다.

apply는 객체를 “바꾸는” 느낌,
also는 객체를 “구경하며 곁들이는” 느낌.

로그, 검증 등 곁다리 작업

also가 잘 어울리는 곁다리 작업을 모아 보면 이렇습니다.

  • 값이 어떻게 흘러가는지 로그로 찍기
  • 값이 올바른지 잠깐 검사하기
  • 만들어진 객체를 다른 곳에 슬쩍 넘겨 두기

이런 작업들의 공통점이 있습니다.
“객체 자체는 바꾸지 않는다“는 점입니다.

그래서 코드를 읽는 사람은
also를 보면 이렇게 이해합니다.

“여기는 본 흐름이 아니라 곁다리구나.”

이렇게 의도가 드러나는 것도
스코프 함수를 쓰는 이유 중 하나입니다.


15.6 this와 it (수신 객체 접근 방식 차이)

수신 객체란 무엇인가

지금까지 두 단어가 계속 나왔습니다.
바로 thisit입니다.

이 둘은 모두 “스코프 함수가 받은 객체“를 가리킵니다.
이 객체를 어려운 말로 수신 객체(receiver)라고 합니다.

수신 객체란,
“이 스코프 함수가 데려온 대상 객체“입니다.

다섯 함수는 이 수신 객체를
this로 부르거나 it으로 부릅니다.
바로 이 차이가 다섯 함수를 가르는 핵심 축 중 하나입니다.

this로 접근하는 함수

run, with, apply
수신 객체를 this로 부릅니다.

this는 생략할 수 있다는 점이 특징입니다.
그래서 객체의 기능을 자기 것처럼 바로 쓸 수 있습니다.

val car = Car().apply {
    color = "red"     // this.color 인데 this 생략
    price = 3000      // this.price 인데 this 생략
}

color, price가 사실은 this.color, this.price입니다.
this를 생략하니 마치 그 객체 “안에서” 코드를 쓰는 느낌이 납니다.

this 방식은 객체의 속성을 여러 개 다룰 때 편하다.
(설정, 초기화 같은 작업)

it으로 접근하는 함수

letalso
수신 객체를 it으로 부릅니다.

it은 생략할 수 없습니다.
대신 이름을 바꿔 줄 수 있습니다.

val name: String? = "홍길동"

name?.let { user ->
    println("이름: $user")
}

it 대신 user라는 이름을 붙였습니다.
이렇게 하면 무엇을 다루는지 더 분명해집니다.

특히 스코프 함수를 겹쳐 쓸 때
it이 무엇을 가리키는지 헷갈리기 쉬운데,
이름을 붙이면 그 혼란을 막을 수 있습니다.

it 방식은 객체를 “하나의 값처럼” 다룰 때 편하다.
(넘기고, 검사하고, 변환하는 작업)

정리: 접근 방식 비교

두 방식을 표로 정리하면 이렇습니다.

접근 방식해당 함수특징
thisrun, with, apply생략 가능, 속성 다루기 편함
itlet, also이름 바꾸기 가능, 값 다루기 편함

여기서 한 가지 감을 잡을 수 있습니다.

  • 객체의 여러 속성을 만질 것 같다 → this 계열
  • 객체를 하나의 값으로 넘길 것 같다 → it 계열

이 감각이 다음 절의 “선택 기준“으로 이어집니다.


15.7 어떤 Scope Function을 선택할 것인가

두 개의 기준으로 나누기

다섯 함수가 헷갈리는 이유는
너무 비슷하기 때문이라고 했습니다.

하지만 사실 딱 두 가지 질문만 하면
대부분 구분됩니다.

첫째 질문: 무엇을 돌려주는가?

  • 블록의 마지막 줄을 돌려준다 → let, run, with
  • 객체 자기 자신을 돌려준다 → apply, also

둘째 질문: 객체를 무엇으로 부르는가?

  • this로 부른다 → run, with, apply
  • it으로 부른다 → let, also

이 두 질문의 답을 조합하면
다섯 함수가 정확히 자리를 찾아갑니다.

다섯 함수 비교표

두 기준으로 다섯 함수를 한 표에 정리하면 이렇습니다.

함수객체 접근반환하는 것대표 용도
letit마지막 줄null 처리, 값 변환
runthis마지막 줄계산 후 결과 반환
withthis마지막 줄한 객체에 여러 작업
applythis객체 자신객체 초기화, 설정
alsoit객체 자신로그, 검증 등 곁다리

이 표 하나면
다섯 함수의 차이가 거의 다 담깁니다.

처음에는 이 표를 옆에 두고 코드를 써도 좋습니다.
쓰다 보면 자연스럽게 외워집니다.

상황별 선택 가이드

표를 실제 상황으로 바꿔 보면 이렇게 정리됩니다.

  • null일 수 있는 값을 다룬다 → let
  • 객체를 만들면서 값을 채운다 → apply
  • 중간에 로그만 살짝 남긴다 → also
  • 한 객체로 여러 작업을 하고 결과를 받는다 → with
  • 여러 줄을 묶어 하나의 결과를 만든다 → run

물론 경계가 애매한 경우도 있습니다.
그럴 때는 “가독성이 더 좋은 쪽“을 고르면 됩니다.

스코프 함수의 목적은 “짧게 쓰기“가 아니라
“읽기 쉽게 쓰기“입니다.

더 자세한 선택표는 부록 C에 실어 두었습니다.
코드를 쓰다 헷갈릴 때 부록 C를 펼쳐 보면 됩니다.


15.8 중첩된 Scope Function 피하기

Kotlin다운 코드란 무엇인가

스코프 함수는 코드를 깔끔하게 만드는 도구입니다.
그래서 잘 쓰면 코드가 정말 읽기 좋아집니다.

이렇게 코틀린의 장점을 살린 코드를
흔히 “코틀린다운(Kotlin-idiomatic) 코드“라고 부릅니다.

예를 들어 이런 코드는 코틀린답습니다.

val user = User().apply {
    name = "홍길동"
    age = 20
}

객체를 만들고 설정하는 의도가
한눈에 명확하게 읽힙니다.

과도한 Kotlin 코드의 함정

그런데 좋은 도구도 지나치면 독이 됩니다.
스코프 함수도 마찬가지입니다.

특히 스코프 함수를
겹겹이 겹쳐 쓰면(중첩하면) 오히려 읽기 어려워집니다.

아래는 나쁜 예입니다.

user.let { u ->
    u.address.let { a ->
        a.city.let { c ->
            println(c.name)
        }
    }
}

let이 세 번 겹쳤습니다.
그리고 itu, a, c로 바꿨는데도
무엇이 무엇인지 따라가기 벅찹니다.

블록이 깊어질수록
“지금 이 it은 누구지?” 하는 혼란이 커집니다.

스코프 함수를 겹쳐 쓸수록
코드는 짧아지지만 읽기는 어려워진다.

이렇게 “코틀린 기능을 과하게 쓴” 코드는
코틀린다운 코드가 아니라
오히려 “과도한 코틀린 코드“입니다.

가독성을 되살리는 방법

방금 본 나쁜 예는
사실 스코프 함수 없이 쓰는 게 더 낫습니다.

val cityName = user.address.city.name
println(cityName)

?가 붙은 null 가능 값이 섞여 있다면
안전 호출(?.)로 이어 붙이면 됩니다.

val cityName = user?.address?.city?.name
println(cityName)

훨씬 짧고, 훨씬 잘 읽힙니다.

정리하면 이렇습니다.

  • 스코프 함수는 한 번에 하나만 쓰는 것이 기본이다
  • 겹쳐 써야 할 것 같으면, 먼저 다른 방법을 의심한다
  • 중간 결과에 이름을 붙여 변수로 나누는 것도 좋은 방법이다

마지막 방법을 코드로 보면 이렇습니다.

val address = user.address
val city = address.city
println(city.name)

줄 수는 늘었지만
읽는 사람은 훨씬 편합니다.

코드는 “쓰는 나“보다
“나중에 읽는 사람“을 위해 쓰는 것입니다.

짧은 코드가 항상 좋은 코드는 아니라는 점,
이것이 이 장에서 가장 기억해야 할 교훈입니다.


15장을 마치며

이 장에서 우리는 다음을 배웠습니다.

  • 스코프 함수는 객체를 잠깐 데려와
    그 안에서 편하게 일하게 해 주는 도구라는 점
  • letit으로 받고 마지막 줄을 돌려주며,
    null 처리에 잘 어울린다는 점
  • runwiththis로 받고
    마지막 줄을 돌려준다는 점
  • applyalso는 객체 자기 자신을 돌려주며,
    각각 설정과 곁다리 작업에 쓰인다는 점
  • 다섯 함수는 “무엇을 돌려주는가“와
    thisit이냐” 두 기준으로 구분된다는 점
  • 스코프 함수를 겹쳐 쓰면
    오히려 가독성이 나빠진다는 점

다섯 함수가 아직 헷갈려도 괜찮습니다.
15.7의 비교표와 부록 C의 선택표를 곁에 두고
직접 코드를 쓰다 보면 몸에 익습니다.

기억할 한 문장은 이것입니다.

스코프 함수는 코드를 짧게가 아니라,
읽기 쉽게 만들기 위한 도구다.

다음 장에서는
또 다른 편리한 문법을 이어서 배워 보겠습니다.